logo
menu

토스 이펙티브 컴포넌트 2 | Select 및 MultiSelect 구현 경험

2023. 10. 24.

  • #리액트

이전 포스트에서 간단한 Dropdown 으로 기능 위주로 설명했는데 스타일링 및 Select 및 MultiSelect 등구현한 내용을 공유하고자 한다.
 
Slash 컨퍼런스에서 아래와 같이 FrameworkSelect 를 Select 를 이용해서 구현했는데 이 Select 는 1탄에서 구현한 Dropdown 의 요소들을 조합하여 구현한 컴포넌트이다. 해당 컴포넌트를 구현해보자
notion image
 

FrameworkSelect 구현

💡
전체 코드는 여기를 참고하세요
notion image
  • 전 포스팅 내용에서 크게 달라진 것은 없다.
  • Dropdown 에서 Menu 와 Item 에 스타일링을 입혔다.
    • 그리고 InputButton 컴포넌트를 구현하였다.

FrameworkSelect 컴포넌트

  • example2/FrameworkSelect.tsx
import { useState } from 'react'; import { InputButton } from './InputButton.tsx'; import { Select } from './Select'; import { useFrameworks } from './useFrameworks.ts'; export function FrameworkSelect() { const { data: { frameworks }, } = useFrameworks(); const [selected, change] = useState<string>(''); return ( <Select // trigger={<InputButton value={selected} />} value={selected} onChange={change} options={frameworks} /> ); }
  • example2/useFrameworks.ts
export function useFrameworks() { return { data: { frameworks: ['Next.js', 'Remix', 'Gatsby', 'Relay'] } }; }
  • 전 포스팅에 구현한 Select 컴포넌트를 사용했으며 useFrameworks 로 UI 와 데이터 로직을 구분하였다.
💡
이렇게 데이터 추상화를 통해 UI 와 관심사를 분리할 수 있음 ⇒ 이런 형태로 데이터에만 집중화해서 모듈화 하는 것을 → Headless 라 함 ⇒ 한 가지 문제에만 집중해서 더 많은 곳에서 사용할 수 있고 격리할 수 있음
 

FrameworkSelect 구현

💡
전체 코드는 여기를 참고하세요
notion image
 
notion image
구현한 결과는 위와 같다.
  • 기존 Select 는 단일만 선택하는 구조인데 MultiSelect 는 복수개를 선택할 수 있고 checkbox 형태로 구현되어있다.
    • 이런 비즈니스 로직과 Checkbox 를 처리하는 로직, Menu 대신 Modal 로 변경되는 로직 등이 크게 달라졌다.

Dropdown.Modal

import { FormEvent, PropsWithChildren, ReactNode } from 'react'; import styled from '@emotion/styled'; import { useDropdownContext } from './context.ts'; type ModalProps = { controls: ReactNode; }; export function Modal({ children, controls }: PropsWithChildren<ModalProps>) { const { isOpen, onSelect } = useDropdownContext(); const handleSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const checkedCheckbox = event.currentTarget.querySelectorAll<HTMLInputElement>("input[type='checkbox']:checked"); const values = [...checkedCheckbox].map((checkbox) => checkbox.value); onSelect(values); }; if (!isOpen) return null; return ( <ModalWrapper onSubmit={handleSubmit}> {children} {controls} </ModalWrapper> ); } const ModalWrapper = styled.form` ... `;
  • Modal 컴포넌트로 controls 로 reset, submit 버튼을 제공하기 때문에 form 으로 감싸서 처리했다.
  • 그리고 submit 을 하는 경우 자식 요소로 가지고 있는 checkbox 중에 check 된 값을 조회해서 해당 값을 onSelect 로 전달하는 방식으로 제어했다.
    • 즉, 비제어 컴포넌트 방식으로 처리했다.
💡
이 외 로직은 기존 Example2 을 그대로 가져가되, Button 및 Checkbox, 연관된 로직 및 타입 정도 변경되었고, Dropdown 의 핵심은 거의 그대로 가져갔다!

 

도메인 분리하기

💡
전체 코드는 여기를 참고하세요
notion image
위와 같이 FrameworkSelect 컴포넌트와 유사한 형태의 컴포넌트가 추가되어야 할 경우 앞선 만든 FrameworkSelect 컴포넌트 를 사용할 수 있을까?
쉽지 않음. 왜냐하면 Framework 도메인에 종속되었기 때문
→ 이를 개선하기 위해서 도메인을 분리하여
 

도메인 분리하기!

notion image
  1. Props 네이밍에서 도메인 맥락 제거하기
    1. ⇒ 도메인 맥락을 제거하면 일반적인 이름으로 바뀌게 됨!
      • 컴포넌트 인터페이스는 일반적일 수록 이해하기 쉬움
        • 왜냐하면 사람은 기존에 알고 있던 내용을 기반으로 파악하기 때문
      ⇒ 💡 그렇기 때문에 최대한 많은 사람이 알 만한 일반적인 단어로 표현해야 의도를 드러내기 쉬움
💡
즉!! 컴포넌트 인터페이스가 표준에 가까울수록 많은 사람들이 쉽게 이해할 수 있음
 

도메인 분리 실습

기존의 FrameworkMultiSelect 컴포넌트를 MultiSelect 로 도메인을 모르게 추상화하고 이는 아래와 같이 개선할 수 있다.
// example4/MultiSelect.tsx import { Button } from './Button.tsx'; import { Dropdown } from './Dropdown'; type MultiSelectProps = { value: string[]; onChange: (value: string[]) => void; options: Array<{ label: string }>; }; export function MultiSelect({ value, onChange, options }: MultiSelectProps) { return ( <Dropdown value={value} onChange={onChange}> <Dropdown.Trigger as={<Button>{String(value.length ? value : '선택하기')}</Button>} /> <Dropdown.Modal controls={ <div style={{ display: 'flex', justifyContent: 'flex-end' }}> <Button type={'reset'}>초기화</Button> <Button type={'submit'}>적용하기</Button> </div> } > {options.map((framework, index: number) => ( <Dropdown.Item key={index} value={framework.label} /> ))} </Dropdown.Modal> </Dropdown> ); }
  • 기존의 FrameworkSelect.tsx 파일을 MultiSelect 파일로 변경하고 Props 의 타입을 누구나 알기 쉽게 value, onChange options 와 같은 형태로 변경하고 변경에 따른 부분을 적용했다.
⇒ 즉 도메인을 모르게되면서 일반적인 MultiSelect 의 이름을 갖게됨!
 
notion image
  • MultiSelect 처럼 도메인을 모르는 컴포넌트와 FrameworkSelect 처럼 도메인을 포함하는 컴포넌트를 구분 지음으로서
    • ✨ FrameworkSelect 는 데이터에 접근하므로 필요한 데이터를 직접 관리할 때 자율적인 컴포넌트가 되고 외부 의존성이 없어 재사용하기 좋음!
      ⇒ 즉, 비즈니스 로직은 스스로 처리하되 UI 로직을 위임하하여 컴포넌트 변경에 용이한 구조로 가져갈 수 있음
 
 

내 계좌 등록하기 페이지 구현

💡
전체 코드는 여기를 참고하세요
notion image
 

구현전 고민!

notion image
  • 좌측에는 은행 목록, 선택한 목록에 따라 우측 패널에 폼이 노출되는 UI
    • ⇒ UI 만 보면 기존에 개발한 것과 다르지만
      ⇒ 이는 기존에 개발한 Select 와 데이터 흐름이 동일함!
      ⇒ 이미 열려있는 Select UI 라고 생각해보면 어떨까?
notion image
  • 좌측 패널은 옵션을 제공하는 메뉴
    • 우측 패널은 현재 선택한 값을 보여주는데 그 값이 다른 컴포넌트로 표현된 부분!

1차 구현

  • 기존 Select 는 Trigger 에 따라 열림/닫힘으로 렌더링했는데 현재 UI 는 항상 열려 있는 구조
    • 그리고 선택한 값에 따라서 특정 컴포넌트를 보여주면 된다.
⇒ 그렇기에 우선 기본 Select 에서 열림/닫힘과 연관있는 isOpen, close, open 등을 제거했다.
// example5/Dropdown/context.ts // 기존 interface DropdownContextValue<T> { isOpen: boolean; select?: T; onOpen: () => void; onClose: () => void; onSelect: (item: T) => void; } // 개선 interface DropdownContextValue<T> { select?: T; onSelect: (item: T) => void; }
notion image
  • Dropdown 컴포넌트도 연관된 부분 제거, 이와 같이 열림/닫힘과 연관된 부분들을 수정했다.
 
Select 컴포넌트 수정
  • 해당 컴포넌트를 적용하는 곳을 보면 뱅크를 선택하는 좌측 패널과 선택한 값을 나타내는 우측패널이 있다.
    • 기존 Dropdown 이 (뱅크 리스트) 좌측 패널에 있고 선택한 값을 나타내기 위해 Select 컴포넌트에 children 을 추가해서 처리했다.
notion image
  • tigger 는 사용하지 않기 때문에 제거했다.
  • children 을 전달해서 렌더링했다.
 
실제적으로 사용되는 코드는 아래와 같다
export function ExampleApp5() { const banks = ['토스뱅크', '카카오뱅크', '신한은행', '국민은행', '하나은행', '기업은행', '우리은행']; const [value, change] = useState<string>('토스뱅크'); return ( <div> <h1>ExampleApp5</h1> <RegisterLayout title="내 계좌 등록하기"> <Select value={value} onChange={change} options={banks}> <RegisterForm /> </Select> </RegisterLayout> </div> ); }
 

2차 구현

💡
전체 코드는 여기를 참고하세요
notion image
하지만 토스에서 발표한 내용을 보면 children 부분에 SwitchCase 문으로 처리했기 때문에 이 부분도 구현해보자!
 
example6/App.tsx
notion image
  • @toss/react 에서 제공하는 SwitchCase 컴포넌트를 이용했다.
  • 또한, 💡 Select 의 children 으로 함수를 전달하여 (14~32 라인) 처리했다.
example6/Select.tsx
notion image
  • 사용하는 곳에서 children 을 함수로 하기 때문에 chilren 타입을 함수로 정의햇다
    • 그리고 사용하는 곳에서 children({selected: value}) 처럼 호출해서 처리했다.
  • 각 아이템을 선택하는 값과 setter 는 Select 에서 관리하도록 했다!!
 

💡 팁!

⇒ UI 에 속지 않고 데이터 흐름을 파악하면? 쉽게 구조를 잡을 수 있음!
⇒ Select 컴포넌트 를 기준으로 잡았고 현재 선택한 값을 render props 로 전달 받도록 구성
⇒⇒ 이제 해당 Select 컴포넌트를 구현만 하면 됨!

컴포넌트 구현전에 고민해보기!!

  • 의도가 무엇인가?
    • 사용하는 입장에서 의도를 파악하기 어려움
  • 이 컴포넌트의 기능은 무엇인가?
    • 컴포넌트가 해야하는 기능이 무엇인지?
    • 어떤 데이터를 관리하고 있는지
  • 어떻게 표현되어야하는가?
    • 인터페이스로 어떻게 표현되어햐는지
⇒ 위 내용이 구현보다 중요!, 왜냐하면 변경하려고 할 때 파악해야 하기 때문에!!
⇒ 인터페이스를 먼저 고민하고 구현한 경험이 도움이 됐다고 함
 
컴포넌트를 나누기 전에 컴포넌트를 나누는 이유에 대해 생각하기 (습관)
  • 모든 로직이 한 곳에 있으면 파악하기 어려움 → 그래서 웹을 구현할때 컴포넌트로 나누어서 관리
  • 반복되는 것을 모듈화하는 것이 좋음
 
지금 컴포넌트를 나누는 행위가
  • 복잡도를 낮추는 것인지?
  • 재사용하기 위함인지
  • 꼭 분리해야하는 컴포넌트인지 고민해보기!!!!
 
💡
여기까지! TOSS SLASH22 의 effective 컨퍼런스 강의의 내용을 정리하고 직접 구현하면서 알아봤다! 비슷한 작업을 여러번 하는 거 같지만, 조금씩 다른점이 있어서 하다보니 모두 구현해봤다! 해당 글이 다른 분들에게 도움이 되었으면 좋겠다!
 

참고